"use client" import { useState, useEffect, useRef, useCallback, useMemo } from "react" import Link from "next/link" import { useParams, useRouter } from "next/navigation" import { getCompare, getCompareReport, stopCompare, resumeCompare, type CompareDetail, type CompareReport, type CompareRunInfo, } from "@/lib/api" import { formatDate, getStatusColor, cn } from "@/lib/utils" import { AccuracyBarChart } from "@/components/accuracy-bar-chart" import { DataTable, type Column } from "@/components/data-table" import { CircularProgress } from "@/components/circular-progress" import { EmptyState, DocumentIcon } from "@/components/empty-state" import { Tooltip } from "@/components/tooltip" const POLL_INTERVAL = 2000 // 2 seconds export default function CompareDetailPage() { const params = useParams() const router = useRouter() const compareId = decodeURIComponent(params.compareId as string) const pollIntervalRef = useRef(null) const [compare, setCompare] = useState(null) const [report, setReport] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [stopping, setStopping] = useState(false) const [continuing, setContinuing] = useState(false) // Check if comparison is in progress const isRunning = compare?.status === "running" || compare?.status === "pending" const isStopping = compare?.status === "stopping" const isPartial = compare?.status === "partial" const isFailed = compare?.status === "failed" const canStop = isRunning || isStopping const canContinue = isPartial || isFailed // Table columns for runs const runColumns: Column[] = useMemo( () => [ { key: "runId", header: "Run ID", render: (run) => {run.runId}, }, { key: "provider", header: "Provider", render: (run) => {run.provider}, }, { key: "status", header: "Status", render: (run) => { const runIsActive = run.status === "running" || run.status === "pending" || run.status === "initializing" || run.status === "stopping" const p = run.progress const total = p?.total || 0 const phasesCompleted = (p?.ingested || 0) + (p?.indexed || 0) + (p?.searched || 0) + (p?.answered || 0) + (p?.evaluated || 0) const totalPhases = 5 * total const progress = totalPhases > 0 ? phasesCompleted / totalPhases : 0 const episodes = p?.indexingEpisodes const hasEpisodeData = episodes && episodes.total > 0 let phasesFullyComplete = 0 if ((p?.ingested || 0) === total && total > 0) phasesFullyComplete++ if ((p?.indexed || 0) === total && total > 0) phasesFullyComplete++ if ((p?.searched || 0) === total && total > 0) phasesFullyComplete++ if ((p?.answered || 0) === total && total > 0) phasesFullyComplete++ if ((p?.evaluated || 0) === total && total > 0) phasesFullyComplete++ const progressContent = (
{runIsActive && } {run.status} {runIsActive && total > 0 && ( {phasesFullyComplete}/5 )}
) if (hasEpisodeData && runIsActive) { const episodeProgress = episodes.total > 0 ? (episodes.completed / episodes.total) * 100 : 0 return (
Episodes Indexed
{episodes.completed}/{episodes.total}
{episodes.failed > 0 && (
{episodes.failed} failed
)}
} > {progressContent}
) } return progressContent }, }, { key: "accuracy", header: "Accuracy", align: "right", render: (run) => { const accuracyPct = run.accuracy !== null && run.accuracy !== undefined ? (run.accuracy * 100).toFixed(0) : null return accuracyPct ? ( {accuracyPct}% ) : ( ) }, }, { key: "date", header: "Date", render: () => ( {compare ? formatDate(compare.createdAt) : "—"} ), }, ], [compare] ) // Silent refresh (no loading state) const refreshData = useCallback(async () => { try { const [compareData, reportData] = await Promise.all([ getCompare(compareId), getCompareReport(compareId).catch(() => null), ]) setCompare(compareData) setReport(reportData) setError(null) } catch (e) { // Silent fail on poll } }, [compareId]) // Initial load useEffect(() => { loadData() }, [compareId]) // Polling when comparison is in progress useEffect(() => { if (isRunning) { pollIntervalRef.current = setInterval(refreshData, POLL_INTERVAL) } else { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current) pollIntervalRef.current = null } } return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current) } } }, [isRunning, refreshData]) async function loadData() { try { setLoading(true) const [compareData, reportData] = await Promise.all([ getCompare(compareId), getCompareReport(compareId).catch(() => null), ]) setCompare(compareData) setReport(reportData) setError(null) } catch (e) { setError(e instanceof Error ? e.message : "Failed to load comparison") } finally { setLoading(false) } } async function handleStop() { if (stopping) return setStopping(true) try { await stopCompare(compareId) await refreshData() } catch (e) { console.error("Failed to stop:", e) } finally { setStopping(false) } } async function handleContinue() { if (continuing) return setContinuing(true) try { await resumeCompare(compareId) await refreshData() } catch (e) { console.error("Failed to continue:", e) } finally { setContinuing(false) } } if (loading) { return (
) } if (error || !compare) { return (

{error || "Comparison not found"}

Back to comparisons
) } return (
{/* Breadcrumb */}
Comparisons / {compareId}
{/* Header */}

{compareId} {compare.status} {canStop && ( )} {canContinue && ( )}

Providers:
{(compare.providers || compare.runs?.map((r) => r.provider) || []).map( (provider) => ( {provider} ) )}
Benchmark:{" "} {compare.benchmark} Judge: {compare.judge} Created: {formatDate(compare.createdAt)}
{/* Run IDs - clickable links to individual runs */} {compare.runs && compare.runs.length > 0 && (
{compare.runs.map((run) => (
{run.runId.length > 16 ? `${run.runId.slice(0, 16)}...` : run.runId}
{run.provider} ))}
)}
{compare.runs && compare.runs.length > 0 && compare.status !== "completed" && (

Runs

router.push(`/runs/${encodeURIComponent(run.runId)}`)} getRowKey={(run) => run.runId} connectToFilterBar={false} />
)} {/* Comparison Tables (when reports available) */} {report && report.reports.length > 0 && (
{/* Overall Accuracy Table */} {/* Accuracy and Latency side by side */}
{/* Overall Accuracy - 35% width */}

Accuracy

{(() => { const rows = report.reports.map((r) => ({ provider: r.provider, correct: r.report.summary?.correctCount ?? r.report.correctCount, total: r.report.summary?.totalQuestions ?? r.report.totalQuestions, accuracy: r.report.summary?.accuracy ?? r.report.accuracy, })) const validAccuracies = rows .map((r) => r.accuracy) .filter((a): a is number => a != null) const bestAccuracy = validAccuracies.length > 0 ? Math.max(...validAccuracies) : null // Only highlight the FIRST occurrence of the best value const firstBestIndex = bestAccuracy != null ? rows.findIndex((r) => r.accuracy === bestAccuracy) : -1 return rows.map((row, index) => { const isBest = index === firstBestIndex return ( ) }) })()}
Provider Score
{row.provider} {row.accuracy != null ? `${(row.accuracy * 100).toFixed(1)}%` : "—"} {row.correct != null && row.total != null && ( ({row.correct}/{row.total}) )}
{/* Latency - 65% width */} {report.reports.some((r) => r.report.latency || r.report.latencyStats) && (

Latency (median ms)

{(() => { const phases = [ "ingest", "indexing", "search", "answer", "evaluate", "total", ] as const const rows = report.reports .filter((r) => r.report.latency || r.report.latencyStats) .map((r) => ({ provider: r.provider, latency: (r.report.latency || r.report.latencyStats)!, })) // Find best (lowest) for each phase and the FIRST index with that value const bestByPhase = phases.reduce( (acc, phase) => { const values = rows.map((r) => r.latency[phase]?.median) const validValues = values.filter( (v) => v !== undefined && v !== null ) as number[] const bestValue = validValues.length > 0 ? Math.min(...validValues) : Infinity const firstBestIndex = values.findIndex((v) => v === bestValue) acc[phase] = { value: bestValue, firstIndex: firstBestIndex } return acc }, {} as Record ) return rows.map((row, rowIndex) => ( {phases.map((phase) => { const value = row.latency[phase]?.median // Only highlight the FIRST occurrence of the best value const isBest = rowIndex === bestByPhase[phase].firstIndex return ( ) })} )) })()}
Provider Ingest Index Search Answer Evaluate Total
{row.provider} {value !== undefined ? ( {value.toFixed(0)} ) : ( )}
)}
{/* Retrieval Metrics Table */} {report.reports.some((r) => r.report.retrieval) && (

Retrieval Metrics

{(() => { const rows = report.reports .filter((r) => r.report.retrieval) .map((r) => ({ provider: r.provider, retrieval: r.report.retrieval!, })) if (rows.length === 0) { return ( ) } // Find best values and FIRST index for each metric const metrics = [ "hitAtK", "precisionAtK", "recallAtK", "f1AtK", "mrr", "ndcg", ] as const const bestByMetric = metrics.reduce( (acc, metric) => { const values = rows.map((r) => r.retrieval[metric]) const bestValue = Math.max(...values) const firstBestIndex = values.findIndex((v) => v === bestValue) acc[metric] = { value: bestValue, firstIndex: firstBestIndex } return acc }, {} as Record ) return rows.map((row, rowIndex) => ( )) })()}
Provider Hit@K Precision Recall F1 MRR NDCG
Retrieval metrics not available
{row.provider} {(row.retrieval.hitAtK * 100).toFixed(0)}% {(row.retrieval.precisionAtK * 100).toFixed(0)}% {(row.retrieval.recallAtK * 100).toFixed(0)}% {(row.retrieval.f1AtK * 100).toFixed(0)}% {row.retrieval.mrr.toFixed(2)} {row.retrieval.ndcg.toFixed(2)}
)} {/* By Question Type - Table and Chart */} {report.reports.some( (r) => r.report.byQuestionType && Object.keys(r.report.byQuestionType).length > 0 ) && (

Accuracy by Question Type

{/* Left: Table (50%) */}
{report.reports.map((r) => ( ))} {(() => { // Collect all question types const allTypes = new Set() report.reports.forEach((r) => { if (r.report.byQuestionType) { Object.keys(r.report.byQuestionType).forEach((type) => allTypes.add(type) ) } }) const rows = Array.from(allTypes) .sort() .map((type) => { const values = report.reports.map((r) => ({ provider: r.provider, accuracy: r.report.byQuestionType?.[type]?.accuracy, })) const validValues = values .map((v) => v.accuracy) .filter((a) => a !== undefined) as number[] const bestAccuracy = validValues.length > 0 ? Math.max(...validValues) : undefined // Find the index of the FIRST best value (for tie-breaking) const firstBestIndex = bestAccuracy !== undefined ? values.findIndex((v) => v.accuracy === bestAccuracy) : -1 return ( {values.map(({ provider, accuracy }, index) => { // Only highlight the FIRST occurrence of the best value const isBest = index === firstBestIndex return ( ) })} ) }) // Calculate overall accuracy for each provider const overallValues = report.reports.map((r) => { const accuracy = r.report.summary?.accuracy ?? r.report.accuracy return { provider: r.provider, accuracy, } }) const validOverall = overallValues .map((v) => v.accuracy) .filter((a) => a !== undefined) as number[] const bestOverall = validOverall.length > 0 ? Math.max(...validOverall) : undefined const firstBestOverallIndex = bestOverall !== undefined ? overallValues.findIndex((v) => v.accuracy === bestOverall) : -1 return ( <> {rows} {/* Overall row */} {overallValues.map(({ provider, accuracy }, index) => { const isBest = index === firstBestOverallIndex return ( ) })} ) })()}
Categories {r.provider}
{type.replace(/[-_]/g, "-")} {accuracy !== undefined ? ( {(accuracy * 100).toFixed(1)}% ) : ( )}
Overall {accuracy !== undefined ? ( {(accuracy * 100).toFixed(1)}% ) : ( )}
{/* Right: Bar Chart (50%) */}
{(() => { // Prepare data for chart const allTypes = new Set() report.reports.forEach((r) => { if (r.report.byQuestionType) { Object.keys(r.report.byQuestionType).forEach((type) => allTypes.add(type)) } }) const chartData = Array.from(allTypes) .sort() .map((type) => ({ type, values: report.reports.map((r) => ({ provider: r.provider, accuracy: r.report.byQuestionType?.[type]?.accuracy, })), })) const providers = report.reports.map((r) => r.provider) return })()}
)}
)} {/* Empty state when no reports yet */} {!report && !isRunning && ( } title="No comparison results yet" description="Results will appear here once the comparison runs complete." /> )}
) }